【第2425期】浅谈 Typescript(二):基础类型和类型的声明、运算、派生
前言
今日前端早读课文章由滴滴@几木授权分享。
@王宏宇,也叫几木,爱好自驾撸猫烫头,来自滴滴小桔车服,致力于体验和产研效率提升,欢迎加入 why318why@gmail.com
正文从这开始~~
【第2424期】浅谈 Typescript(一):什么是Typescript?我们了解到,Typescript 构造了两个相对独立的空间。这篇我们先把目光放在「类型声明空间」的表现,即基础类型和类型的声明与运算。
本文你将看到:
Typescript 可以直接拿来用的基础类型
Typescript 声明类型的方式和区别,一些核心用法的理解
Typescript 类型的运算和派生
本文你不会看到:
Typescript 各种语法和API的详细罗列
Typescript 配置项的罗列和解释
1 基础类型
我们先来关注「类型声明空间」的万物之源 —— 基础类型(上图框起来的部分)。「类型声明空间」中的基础类型,就像「变量声明空间」中的 1, 'string', true...。在类型声明空间中,我们既可以直接使用基础类型,也可以通过声明、运算、派生来构造、流转新的类型。
const foo: number = 10 // 直接使用
type bar = number // 赋值给类型声明
number / string / boolean / null / undefined
「类型声明空间」有五种基础类型用来对应「变量声明空间」中,JS 的五种原始值。
const n: number = 10;
const s: string = 'string';
const b: boolean = true;
const n: null = null;
const u: undefined;
null / undefined 是所有类型的子类型
默认情况下null和undefined是所有类型的子类型。就是说你可以把 null和undefined赋值给number类型的变量。
你可以给一个有类型的变量赋值同样类型的值,也可以赋值为null / undefined ,他们能单向给任意别的类型赋值。
any
强制关闭当前变量的类型检查。
any 类型在 TypeScript 类型系统中占有特殊的地位。它提供给你一个类型系统的「后门」,TypeScript 将会把类型检查关闭。在类型系统里 any 能够兼容所有的类型(包括它自己)。因此,所有类型都能被赋值给它,它也能被赋值给其他任何类型。
let foo: any = 1;
foo = 'string'; // 没问题,因为 any 把 foo 的类型检查关闭了
反应函数返回的类型 void / never
void
主要用在函数声明中,表示没有返回值的函数的返回值类型。
const VoidFunc = (): void => {};
const returnOfVoidFunc = VoidFunc(); // void
那我直接声明一个 void 类型会怎么样呢?这是没什么意义的,因为你只能给它赋值为null / undefined。
const aVoid: void = undefined;
never
不返回和没有返回值是两回事。和 void 不同,never 是 TypeScript 中的底层类型,表示那些永不存在的值的类型。
放到函数里说,就是一个不会返回的函数的返回值类型。什么情况下函数会没有返回呢?两种情况:
函数内有死循环
函数总抛错
// 1. 函数内有死循环
const NeverFunc = (): never => {
while(true) {}
};
// 2. 函数总抛错
const NeverFunc = (): never => {
throw new Error();
};
也可以当类型注解,但只能赋值为另外一个 never,可以放在一段「我认为永远执行不到的代码段」内做「类型保护」(关于类型保护,后面文章会说)。
const neverRetch: never;
数组和元组
数组类型有两种声明方式(其中第二种是「范型」的应用,将在后面介绍)
const arr: number[] = [1,2,3];
const arr: Array<number> = [1,2,3];
如果你确定数组的元素数量和类型(甚至是不同的类型),则可以用更严谨的类型——元组。元组在「变量声明空间」也完完全全是个数组,只是在「类型声明空间」比数组类型更精细。
const status: [ number, string ] = [ 1, '已完成' ];
// 如果你从元组中取某个元素,得到的类型也是正确的
status[0] // number
// 如果越界了,得到联合类型
status[2] // number | string
2 类型声明
上一章我们介绍了一些基础类型,基础类型是可以「直接拿用来注解的」,就像我们在 JS 里可以直接console.log(1)一样。但有时候我们需要通过类型声明,在基础类型的基础上,进行类型的别名、组合,和复杂类型的创建,就像 JS 里 const、let、var、function、class 等关键字。
同样,在类型声明空间中,也可以通过一些关键字声明类型:
type:声明一个类型别名
interface:声明一个接口
class:声明一个类
enum:声明一个枚举
namespace:声明命名空间
module:声明模块
这些关键字不仅在声明产物上更复杂,在运行机制上也有所区别,如下图:
type、interface 是「纯类型声明」,只在类型声明空间产生声明;其中type 要比 interface 更灵活,可以赋值为基本类型或其他声明产生的类型
class 本来是 js(ES)的语法,ts 做了补充,使之能同时产生一个类型声明
enum、namespace 是「对变量声明空间有扩展的类型声明」,不但在类型声明空间产生声明,也在变量声明空间构建了特殊的数据结构。要注意两个空间中的声明的关系和区别,不然很容易搞混。
编译行为对类型声明空间的剔除
如图所示,有的声明影响绿色的类型声明空间,而有的“污染”了黄色的变量声明空间。无论如何,JS 在编译后都会把绿色部分剔除掉,而黄色部分转换为可执行的JS结构。两个例子:
/* === 1、type 声明 === */
type t = number
const foo: t = 1
// 编译后
const foo = 1
/* === 2、enum 声明 === */
enum Color {
Red = 0,
Green = 1,
Blue = 2
}
// 编译后
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));
type
type 在类型声明空间中,相当于变量声明空间的 const/let/var,用来声明一个类型别名。
type t = number
const foo: t = 1
你可以在声明等号右侧放任何可以直接注解的东西,比如「原始类型、对象/函数声明、传递其他类型声明、类型运算和派生表达式、类型捕获表达式」,包括“接口”,如下:
type Foo = {
bar: number;
}
interface
TypeScript的核心原则之一是对值所具有的结构进行类型检查。
那么如何检查一个复杂的值结构呢?interface 可以给::对象、函数::定义类型。可以理解为::「一个可调用类型及其调用模式」::。和 type 一样,interface 并不污染变量声明空间。
接口是对调用模式的描述
对象和函数都是可调用的:对象通过foo[bar]调用属性或方法,函数通过foo(bar)、new foo(bar)调用。
// 声明对象 interface
interface Foo {
bar: number;
}
// 声明函数 interface
interface Foo {
(bar: number): void;
}
// 声明构造函数 interface
interface Foo {
new (bar: number): Foo
}
灵活的 interface 声明
此外,interface 提供多种关键词实现灵活的声明语法,主要的几个如下:(具体不展开,参考接口 · TypeScript中文网 · TypeScript——JavaScript的超集)
索引类型
readonly:只读接口属性
extends:接口扩展
implements:接口实现
type/直接注解和interface声明的区别
前面说了,我直接搞一个{ bar: number }也能直接注解对象,或者赋值给type,那么type 声明/直接注解的和 interface 声明的有什么区别呢?
interface 可以 merge(重复声明,并合并属性),如下,但 type 不行
interface Foo {
bar: string;
}
interface Foo {
baz: number;
}
const foo: Foo = { bar: 'bar', baz: 1 };
参考:TypeScript: Documentation - Everyday Types
class
class 本来是 js的语法,关于它在变量声明空间的用法就不展开了。
class 在类型空间
当你在 ts 中声明一个 class Foo,它会保留 js 中的 class Foo 声明,同时在类型空间声明一个类型 Foo(两空间命名可以重复)。这个类型 Foo 表示的是:::Foo 类的实例类型。::,可以直接当成 interface 来用。
class Foo {
bar: number;
baz() {}
}
const foo: Foo = new Foo()
// 等价 interface
interface Foo {
bar: number;
baz: () => void;
// 成员类型既可以是明确注解的,也可以是推测出来的
}
class 实例和 interface 的区别
最重要的一点是,要拎清楚,interface 是纯「类型声明空间」的产物,编译就没了;但 class 可是有实实在在的「变量声明空间」实现,也因此class 可以正常被实例化、继承、调用,interface 不行。
有人说那我 declare 一个 class 当 interface 用不行吗?做人要负责的,既然 declare 了 class,TS 就会认为你真的有 class 实现,如果你还真没有 class 实现(比如从JS引入的),那就是个坑了。
enum
enum 是 ts 创造的一种声明,表示枚举,用法如下面代码:
enum Color {
Red,
Green,
Blue,
}
// 创建了三个 Color 枚举,可以这样引用
const red = Color.Red; // 这里 red 其实会被赋值为 0,枚举的默认行为是以 0,1,2... 类似 index 的方式赋值
显然,enum 已经干扰到变量声明空间的赋值行为了。因此 enum 不仅在类型空间产生声明,也在变量空间构建了特殊结构。下面我们讨论下它在类型声明空间和变量声明空间的表现。
enum 在类型声明空间
上面声明的 Color 在类型空间,相当于声明了一个和枚举类型相同的 Color 类型(这里是 number)
const color: Color = 0;
// 相当于
const color: number = 0;
// 注意是 number,而不是 0|1|2,因此你这样赋值也不会报错
const color: Color = 999;
enum 在变量声明空间
那么如何实现枚举能力呢,编译后可以看到,enum 会在变量空间创建一个「有枚举特性」的 Color 变量:
// 编译后
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));
这段代码创建了这样一个 Color 变量:{0: "Red", 1: "Green", 2: "Blue", Red: 0, Green: 1, Blue: 2}
// 我们可以通过枚举成员拿到它的关联值
const red: Color = Color.Red
// 也可以通过关联值拿到枚举成员名,这在反查的时候非常有意义
const key: string = Color[0]
鸡肋的字符串关联值
为了更语义化,ts 还支持字符串类型关联值:
enum Color {
Red = 'red',
Green = 'green',
Blue = 'blue',
}
我个人不喜欢这么用,因为所谓的语义化并不那么必要,连枚举成员名都解释不清楚的枚举还叫枚举么?更主要的是,用字符串丧失了「反查」的能力:
// 编译后的字符串类型关联值枚举
var Color;
(function (Color) {
Color["Red"] = "red";
Color["Green"] = "green";
Color["Blue"] = "blue";
})(Color || (Color = {}));
也因此,丧失了直接赋值的能力:
const c: Color = 'green'; // Type '"green"' is not assignable to type 'Color'.
namespace
js 中,我们通常用一个自执行函数包装出一个「命名空间」,这样 foo 不会污染到外层变量命名空间,而只能通过 something 访问到。
(function(something) {
something.foo = 123;
})(something || (something = {}));
ts 提供了 namespace(以前叫内部模块),在变量空间封装了这种做法,同时在类型空间提供被包裹的声明方式。
namespace something {
export const foo: number = 123
export interface Foo {
bar: string;
}
}
namespace 在变量声明空间
很显然,namespace 要侵入变量声明空间。上面声明编译后:
// 和我们自己的包法,一模一样
var something;
(function (something) {
something.foo = 123;
})(something || (something = {}));
// 可以通过层级访问到内部变量
console.log(something.foo);
// (foo 留着;interface Foo 部分被编译清掉了)
namespace 的更多玩法
除了最简单的包裹变量声明、类型声明,namespace 还可以做到:(参考 命名空间 · TypeScript中文网 · TypeScript——JavaScript的超集)
多层嵌套
多文件共同维护一个 namespace 声明
为任意层创建别名
module
为什么说「namespace 以前叫内部模块」呢?因为以前没有 namespace 关键词,命名空间的能力是通过 module 实现的。
module something {
export const foo: number = 123
}
后来换了更贴切的 namespace,module 也就只用在「外部模块」上了。现在,我们通常只能在 declare 时看到 module 用于补充外部 js 模块的类型。
// path.d.ts
declare module 'path' {
export const path: any;
}
3 类型运算和派生
对于基础类型、已声明的类型,我们可以通过运算和派生转换为另外一种类型,拿来直接注解或者给类型声明用。
联合类型
在某些场景下,我们需要表示「可能的多种类型」,比如一个fontWeight,值可能是700或者'bold'。在 TS 中是这样表示的:
const fontWeight: string | number = 700;
字面量类型
更具体的,我们可以直接以联合的形式声明字面量类型。随后的赋值只能以被联合的字面量之一,有种乞丐版枚举的意思。
// 字符串字面量
const display: 'flex' | 'block' = 'block';
// 数字字面量
const status: 0 | 1 | 2 | 3 = 2;
联合类型和类型保护
通俗点说,当你用一些逻辑让联合类型注解的变量走到分支,它的联合类型也会缩小范围。
// 比如一个函数
const getStyle = (display: 'flex' | 'block') => {
if (display === 'flex') {...}
else {
// 这里,display 的类型就会缩减到 'block' 上
}
}
事实上 TS 更智能,甚至能通过两个联合的接口的属性类型来剔除“不可能的部分”。这种特性叫“可辨识联合”,后面会详细介绍。
交叉类型
和联合类型的“或”不同,交叉类型是“且”,把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。比较常见的场景是接口合并,比如:
interface BaseInfo {
name: string;
id: number;
}
interface ConnectInfo {
phone: number;
mail: string;
}
type PersonInfo = BaseInfo & ConnectInfo;
// 这样,PersonInfo 接口需要同时实现 BaseInfo 和 ConnectInfo 的属性声明
范型
我们回顾前面数组声明的第二种用法:
const arr: Array<number> = [1,2,3];
可以这样理解:我们以 <>的形式为类型Array传入类型number,就得到了一个包装后的“元素类型为 number 的 Array”。就像一个类型工厂,或者类型函数。
但范型更重要的意义是,一旦范型 T(或者别的什么)在入口定义,其内部所有用到 T 的地方都将保持该“可变类型”的一致性。
interface 中的范型
比如我们要封装一个网络请求的返回类型,它外层有通用的 code,然后通过 data 链接到“不可预测”的数据结构上。这时通过传入的范型,就可以获得一个 data 类型很具体的返回类型。
// interface 的范型入口在声明后
interface ResponseType<DataType> {
code: number,
data: DataType
}
// 调用
type myResponse = ResponseType<string> // 获得一个 { code: number; data: string; } 的类型
class 中的范型
比如我们有个 Queue 列,但无法确定队列元素的类型,就可以开放范型。(当然你也可以直接置为 any,就丧失了对元素类型的准确控制)
// class 的范型入口在声明后
class Queue<T> {
private data: T[] = [];
push = (item: T) => this.data.push(item);
pop = (): T | undefined => this.data.shift();
}
// 调用
const queue = new Queue<number>();
queue.push(0);
queue.push('1'); // Error:不能推入一个 `string`,只有 number 类型被允许
函数中的范型
同样是Queue 的函数版,参数和返回都能取到传入的类型。
// 函数的范型入口在如参前
const getQueue = <T>(firstItem: T): T[] => {
return [firstItem];
}
// 调用
getQueue<number>(1)
getQueue<number>('Robin') // Error
不必传入:范型推论
编译器会根据传入的参数自动地帮助我们确定T的类型
比如上面函数,我直接调用 getQueue,T 作为入参,会被推断为number,并保持整个声明内一致,即返回值为number[]。
// 函数的范型入口在如参前
const getQueue = <T>(firstItem: T): T[] => {
return [firstItem];
}
// 调用
getQueue(1)
不要无脑上范型
范型主要用于保持声明内部的一致,不要为了用而用。如果只有一处用到,自然不存在一致的问题,相比用范型,直接用 any 把类型“做掉”更简明。
const foo = <T>(bar: T): void => {}
// 不如这样
const foo = (bar: any): void => {}
索引类型:查询和访问
keyof:索引查询
有时我们需要获取一个 interface 的索引类型(比如解析页面参数的时候),就要用 keyof 关键字。索引类型可能是字面量或者别的,要看 interface 是如何构造的。keyof 相当于把接口的索引归纳到一个类型上。
// interface 以具体 key 声明
interface UrlQuery {
from: string;
resourceId: string;
}
// keyof 取出的是字面量类型
type KeyType = keyof UrlQuery; // KeyType 为 'from' | 'resourceId';
// interface 以可索引声明
interface UrlQuery {
[q: string]: string;
}
// keyof 取出的是索引类型
type KeyType = keyof UrlQuery; // KeyType 为 string | number
注意最后一个 case,为什么是 string | number,参考 typescript - Keyof inferring string | number when key is only a string - Stack Overflow
索引访问
在一个已声明的接口或数组上,我们可能需要获取某个接口属性或者数组元素的类型
interface UrlQuery {
from: string;
resourceId: string;
}
// 获取接口属性的类型
UrlQuery['from'] // string
type QueueType = string[];
// 获取数组元素的类型
QueueType[0] // string,给 1、2... 都可以
映射类型
前面的 keyof,是吧接口的索引归纳到一个类型上。在 TS 中,同样有对应的反向操作,即把索引的联合类型映射回来。
type Params = 'from' | 'resourceId';
type UrlQuery = {
[P in Params]: string;
}
有什么用呢?比如我们实现一个 Partial,也就是把传入接口的所有属性都变为可选的,结合索引和映射:
type Partial<T> = {
[P in keyof T]?: T[P];
}
注意:interface 不支持
所以我们在例子里只能用 type 声明,在很多时候是一样用的。
Utility Types
如上我们实现的 Partial,其实早已被 TS 内置,此外 TS 还提供了更多工具类型,都是以范型的形式存在的,特别方便于我们在类型声明空间中进行类型的转换。常见的如:
Partial、Required、Readonly:用于映射接口属性的可选性
Exclude、Extract:用于调整接口的属性列表
ReturnType:获取函数返回类型
完整列表参考:TypeScript: Documentation - Utility Types
小结
本篇我们主要围绕「TS 在类型声明空间中的行为」展开讨论。
TS 在类型声明空间中,像 JS 一样,提供一些基础值、声明和运算语法,基础类型被后两者反复加工,衍生出非常丰富的类型。
基础类型主要针对 JS 的几种基础值,做对应的注解
类型声明可以自定义类型名称,声明丰富的类型结构,有的甚至能干扰到「变量声明空间」
各种运算和派生语法,能把一种或几种类型转换为其他类型,进一步增加了「类型声明空间」的丰富程度
关于本文
作者:@几木
原文:https://zhuanlan.zhihu.com/p/389888266
浅谈Typescript系列
【第2424期】浅谈 Typescript(一):什么是Typescript?
欢迎自荐投稿,前端早读课等你来。